iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 5
2
Software Development

用js成為老闆心中的全端工程師系列 第 5

Day 5 - 一周目- 從VSCode debug 模式看作用域(Scope)、this、閉包(Closure)

  • 分享至 

  • xImage
  •  

2019-12-30 this:呼叫函數的人 勘誤中,參考:討論文

回憶

昨天提到了用 debug 模式玩ES6的基本語法。

目標

以 VSCode dubug 模式來看,經典的觀念:作用域(Scope)、this、閉包(Closure)

函數(function)

函數可看成一群程式碼的集合,可以幫我們包裝 routine 的工作 (可以重複呼叫),命名後可以增加程式碼可讀性。
函數也引發了變數作用域、閉包、this 的問題。

基本宣告

有兩種方法可以宣告函數

  1. 函數物件
    function sayHi() {
        console.log('Hi!)');
    }
    
  2. 箭頭函數arrow function
    const sayHi = () => {
        console.log('Hi!');
    }
    

var/let/const 作用域(Scope): 變數生存的空間

接下來會用 debug 模式,觀察 var/let 的特性。

在 ES6 出來以前只有 var 可以用,這是指在宣告在函數內的變數,在這函數的執行過程中會一直在,不管包幾層區塊。

在區塊 ({…}) 內宣告的變數可以在區塊外使用嗎?

我們觀察以下程式碼:

// var/let in global
const runIf = true;
if(runIf) {
    var ifVar = 'ifVar'; // if 執行完會留下
    let ifLet = 'ifLet';
}
console.log(ifVar); // 執行到這行,可存取到 ifVar,因為 ifVar 是在主程式函數中宣告的
// console.log(ifLet); // ReferenceError: ifLet is not defined


// var/let in function
function fun1() {
    var innerVar = 'fun1Let'; // fun1()執行完不會留下
    let innerLet = 'fun1Let'; // fun1()執行完不會留下
}
fun1();
// console.log(innerVar); // ReferenceError: innerVar is not defined
// console.log(innerLet); // ReferenceError: innerLet is not defined
console.log('bye');

在第2,5,13,19行下中斷點,執行 debug,如下圖:
https://ithelp.ithome.com.tw/upload/images/20181005/20110371nSWPRmuito.png
停在第2行後,看看 CALL STACK ,目前執行在匿名函數中 (anonymous function)中,也就是,當程式執行時,我們可以假想它們被包在某個函數中且立刻被執行,像是:

(anonymousFunction() {
  // …上面程式碼…
})()

此外, VARIABLES->Local 中有在函數內可以存取的變數,但會發現 沒有 ifLet ,也就是第8行不能讀到ifLet的原因。

再往下執行到第5行,
https://ithelp.ithome.com.tw/upload/images/20181005/20110371dmjDNs8CQU.png
多出 VARIABLES->Block ,裡面有ifLet,而 VARIABLES->Local 還留著。

再往下執行到第13行,
https://ithelp.ithome.com.tw/upload/images/20181005/20110371sIfje40Hpo.png
CALL STACK 現在進入到 fun1 中, CALL STACK 自然就只剩下 innerLetinnerVar ( this 晚點說)

再往下執行到第19行,
https://ithelp.ithome.com.tw/upload/images/20181005/20110371QmYN8u5RYu.png
離開 fun1()innerLetinnerVar就會被消毀,當再次回到「進來前的函數空間」, innerLetinnerVar 當然就存取不到了,也就是第17,18行不能讀到他們。

可以試試把第 8, 17, 18註解拿掉,會丟出例外

我們整理結論:

  1. var 是屬於函數作用域(function scope),活在函數中,出現在 VARIABLES->Local
  2. let 是屬於區塊作用域(block scope),活在 {…} (curly brackets),出現在 VARIABLES->Block

那…const 呢? 它跟 let 一樣,只是變數不能再次被賦值(=)。
所以結論:

  1. var 是屬於函數作用域(function scope),活在函數中,出現在 VARIABLES->Local
  2. let/const 是屬於區塊作用域(block scope),活在 {…} (curly brackets),出現在 VARIABLES->Block

要怎麼選用它們?

我的做法是變儘量限制它們的 scope,以降低無法預期的效果,像是:var 變數生存太久佔用記憶體、本應該是常數的東西不小心執行時被改到、存取到本不應該存在的變數…等

  1. 能用 const 儘量用
  2. 可能要改值,就用 let
  3. 最後才用 var

this:呼叫函數的人

this 在OO(物件導向)技術被用來當做實例(instance)的代理變數。在 javascript 也有類似的功用,但 this 是可以被我們動態替換的,所以可以做的更多,見:JavaScript - call,apply,bind。現階段只要了解:this就是呼叫函數的人

看以下的程式,下中斷點觀察

const funA = () => {
    console.log(this);
};
function funF () {
    console.log(this);
};

const obj = {
    funA: funA,
    funF: funF,
}

funA();
funF();

obj.funA();
obj.funF();

下面這張圖,程式是從第14行進入至第3行,呼就叫的人是誰?因為沒有指明人,就會拿最上層的人(物件),所以就叫 global,此時 thisglobal
https://ithelp.ithome.com.tw/upload/images/20181005/20110371yLygBhZqEB.png

下面這張圖,程式是從第17行進入至第3行,呼就叫的人是誰?是 obj,所以 thisobj 且它的型別是 Object,打開來看看真的是它
https://ithelp.ithome.com.tw/upload/images/20181005/20110371EKhaLrHDb6.png

閉包(Closure):把外面的變數關在函數中使用

我們考慮以下問題:

  1. 函數外宣告的變數,能不能在函數內使用?
  2. 函數內如何使用外部變數的值?
  3. 閉包域中的外部變數值可以被修改嗎?

回答這些問題

  1. 可以,每個建立一個函數物件時,會會有一個閉包域產生。當函數物件執行時可以存取外部變數
  2. 一般有兩個方法
    1. 用參數,把值傳入
      const outer = 'outer';
      function fun(a){
        console.log(a);
      }
      fun(outer);
      
    2. 透過閉包,把外部變數包入閉包域
      const outer = 'outer';
      function fun(){
        console.log(outer); // 引用到外部變數,所以會放到 fun()函數中的閉包域
      }
      fun();
      
  3. 可以,因為閉包域中的變數和外部變數是相同的記憶體位置,所以可以被修改。見下面說明。

執行以下程式,並下中斷點,為了看的更清楚我們很刻意的放到 main() 中執行,

function main() {
    let outer = 'outer'; // 外部變數
    function funA() {
        console.log(outer); // 讀取到外部變數
    };

    function funB() {
        const inner = outer; // 內部變數,指向 outer 的值
        outer = outer + '-fix'; // 修改 outer 的值
        console.log(inner, outer);
    };

    funA();
    funB(); // outer 值被修改
    funA();
};

main();

下圖中,outer 放入 VARIABLES->Closure 閉包域中,使我們可以存取它的值。
https://ithelp.ithome.com.tw/upload/images/20181005/20110371bEp1s6qXol.png

下圖中,因為宣告了 const inner,它是屬於 VARIABLES->Local ,並設定成outer的值,所以 inner = 'outer'。然而,outer = outer + '-fix',把 outer 改成了 outer-fix的值。此外,outer 也被放入 VARIABLES->Closure 閉包域中。
https://ithelp.ithome.com.tw/upload/images/20181005/20110371HpOalp20fF.png
用記憶體圖示來說,就會很清楚了,白正方形是記憶體空間,裡面會放字串值。
https://ithelp.ithome.com.tw/upload/images/20181005/20110371CvVeDt0zkW.png

最後在呼叫一次 funA(),得到 outer 被修改後的結果。
https://ithelp.ithome.com.tw/upload/images/20181005/20110371xKGxbhDeoL.png

什麼時候用?

可以用閉包包入定值,但很煩,ES6 引入的 let 可以簡化不少。

// i, j loop 完,變成定值
let funs = [];
for (var i = 0; i < 3; i++) {
    var j = i;
    funs.push(function () {
        console.log(i, j); // loop 完才用到 i, j
    });
}
funs.forEach(fun => fun());

// 用閉包
funs = [];
for (var i = 0; i < 3; i++) {
    (function () {
        var j = i; // 把值存入一個匿名函數的閉包
        funs.push(function () {
            console.log(i, j);
        });
    })();
}
funs.forEach(fun => fun());

// 用 let
funs = [];
for (var i = 0; i < 3; i++) {
    let j = i;
    funs.push(function () {
        console.log(i, j);
    });
}
funs.forEach(fun => fun());

結果:

3 2
3 2
3 2
3 0
3 1
3 2
3 0
3 1
3 2

只有後面兩種寫法會正確。

復雜習題

可以猜看看下面的結果:

const funs = [];
for (var i = 0; i < 3; i++) {
    const j = i;
    funs.push(function () {
        const inner = i;
        console.log(inner, i, j);
    });
}

funs.forEach(fun => fun());

總結

今天用 debug 模式,觀察作用域(Scope)、this、閉包(Closure)的例子,並發現下面的關連性。
VARIABLES->Local - var
VARIABLES->Block - let/const
VARIABLES->Closure - 閉包

參考連結


上一篇
Day 4 - 一周目- 用VSCode debug 模式,玩玩 ES6 常用語法
下一篇
Day 6 - 一周目- 程式碼品質工具ESLint,照顧程式碼風格
系列文
用js成為老闆心中的全端工程師31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
2
chichi
iT邦新手 3 級 ‧ 2018-10-13 13:50:50

funs.forEach(fun => fun());
fun => fun() 這是什麼意思 ?

看更多先前的回應...收起先前的回應...

fun => fun() 是一個箭頭函數,可以想成

function (fun){
    return fun();
}

箭頭函數回傳若不同時寫 {}return,就是直接回傳

funs從命名來看是一個 ”函數”的陣列,所以就是歷遍陣列內的所有函數。

funs.forEach(
    function (fun){
        return fun();
    }
);
chichi iT邦新手 3 級 ‧ 2018-10-13 20:57:54 檢舉

其中下面這一段我也看不懂它的包法!!

(function () {
        var j = i; // 把值存入一個匿名函數的閉包
        funs.push(function () {
            console.log(i, j);
        });
    })();

謝謝你的提問,這問題問的很好/images/emoticon/emoticon12.gif

上層的(function () {}) 匿名函數,把 i 放入此匿名函數的閉包。假設 i = 0,因為(function () {})() 會立刻執行,一執行到var j = i時,會進行賦值 j 被設為 0,且這 j是這匿名函數的Local空間。
https://ithelp.ithome.com.tw/upload/images/20181013/201103714c1axI1DZa.png

然後看

funs.push(function () {
    console.log(i, j);
});

這裡的

function () {
    console.log(i, j);
}

又產生了閉包,j 被它包著了,而它是來自上一個匿名函數且值是 0。

當 loop 結束後,

function () {
    console.log(i, j);
}

一個個被執行,j 是來自上一層匿名函數的值,每個上一層匿名函數有自己的 j ,且值都不一樣。

然而i就不一樣了,他是來自全域的,loop 結束後值就是 3,所以才會得到

3 0
3 1
3 2

把中斷點下在

  1. var j = i; // 把值存入一個匿名函數的閉包
  2. console.log(i, j);

你可以比較清楚看到執行過程

1
文祥
iT邦新手 5 級 ‧ 2019-02-13 14:33:47

谢谢作者写出这么好的文章,留言这么少,果然是曲高和寡啊!/images/emoticon/emoticon01.gif

0
亞歷斯
iT邦新手 5 級 ‧ 2019-10-29 18:28:32

你好,我在做 this 部分的實作時出現一個問題,在開始debugging從第13行執行到第2行時,VARIABLES顯示在funcA hapi 的this會是一個空的Object:
https://ithelp.ithome.com.tw/upload/images/20191029/20117089EY1krPpywj.png

以下是我的launch.json設定
https://ithelp.ithome.com.tw/upload/images/20191029/20117089u2DHN36Oqd.png

請問可能是什麼原因造成的呢?

deh iT邦研究生 1 級 ‧ 2019-12-23 10:28:43 檢舉

跟你一樣的狀況
funA();
funF();
obj.funA();
的this都是global

只有obj.funF();的this才是object

我現在用 Nodejs 12 跟你們的的結果一樣,跟內文有差異。

箭頭函數和一般函數是不一樣的東西,在{}裡面使用 this是不同的意義。在箭頭函數中用this時,其值會與宣告箭頭函數的 lexical context相同。一般函數的 this 才會是呼叫函數的人。

我對於 this:呼叫函數的人 的解釋不完全正確,請見諒,我想需要說明並校正內容。

參考:

  1. Are 'Arrow Functions' and 'Functions' equivalent / exchangeable?
  2. function's this keyword-Arrow functions
  3. What is the difference and relationship between execution context and lexical environment?

我要留言

立即登入留言